Skip to content

Promote to main: close the stamp ownership hole - #315

Merged
crtahlin merged 2 commits into
mainfrom
dev
Aug 30, 2026
Merged

Promote to main: close the stamp ownership hole#315
crtahlin merged 2 commits into
mainfrom
dev

Conversation

@crtahlin

Copy link
Copy Markdown

Closes #312 on production, where the hole is still open.

Merge with a merge commit, not a squash — the merge base is currently correct (f26f695) and this PR shows only the 10 files that actually changed. Squashing would break that again.

Why this matters now

Production's batches are all untracked, which means writable by anyone who reads a batch id out of GET /api/v1/stamps/:

0ec135015577fb66  depth=17  accessMode=None  util=50.0   <- filled by someone who never acquired it
5a6acafe61e3eec7  depth=20  accessMode=None  util=0.0
6710b6ff683af5e4  depth=17  accessMode=None  util=0.0
9e47a43d2f355c16  depth=20  accessMode=None  util=0.0
a58eacd75ce5fae3  depth=17  accessMode=None  util=0.0
e381fa6926b90bbe  depth=17  accessMode=None  util=0.0

The gateway pays roughly 0.018–0.14 BZZ a day per batch to keep these alive.

What lands

Ownership. check_access refused nothing when a batch was absent from the registry, commented "backward compatibility". The set that covered was not legacy callers — every path a caller obtains a batch through already registers it — it was the pool's own inventory, because ownership was recorded on acquire, never on purchase. The pool now claims what it buys, and adopts what it already holds on sync, so batches bought before this are protected rather than staying open for the rest of their lives.

accessMode reports pool. Found reviewing the above: the mapping is "owned" if mode == "paid" else "shared", and pool inventory registers with mode="pool" — so it would have been reported as shared, the value that tells a client a batch is free to use, about exactly the batches now refused. Permissive-and-honest would have become actively misleading.

STAMP_OWNERSHIP_ALLOW_UNTRACKED restores the old behaviour for one case: the registry file is lost, every batch becomes untracked at once, and legitimate owners are locked out of batches they paid for. A test pins that it does not reach pool inventory — recovering from a lost registry must not reopen the hole.

Config propagation documented in CLAUDE.md: what takes effect when, why docker restart does not reload env_file, why a bind-mounted file pins its inode, and which repo files are never deployed.

Verified on staging after deploy

7eba1cd5fa988dc1  depth=20  accessMode=pool     <- bought after the fix, claimed at purchase
6909e6d6a3287310  depth=20  accessMode=shared   <- acquired, transferred to the caller
0219f509646f5f30  depth=17  accessMode=None     <- not pool inventory

Three distinct states, each honest.

Ordering

provenance-smasher#31 is already merged. It had to be: ACCESS_MODES did not include "pool" and would have failed on every pool batch.

Effect on production at deploy

Existing inventory is adopted on the first sync, so the currently-open batches are claimed without manual intervention. The 50%-utilised batch keeps its data — this stops further writing, it evicts nothing. Downloads are unaffected and remain free.

Pre-flight

check result
full suite 1083 passed, 25 skipped
production now ok, 0.233.c1131fc, bee healthy, 134 peers
pool 2 stamps, target {20: 1}, no deficit
balance 17.4650 BZZ

…314)

Every one of these has cost time, and the shape is always identical: a change is
made, something reports success, and nothing happens.

A table of every configuration surface — GitHub variables, the two env files, the
host env file, config.alloy, the Caddyfile, the Grafana JSON — saying when a
change reaches the running system and whether a deploy overwrites it. Then the
three that are genuinely surprising.

docker restart does not reload env_file. Docker reads it when it CREATES a
container, not when it starts one. Editing /opt/swarm_connect.env and restarting
changes nothing, and the container reports healthy on the old configuration. That
cost an incident: the pool target was lowered, the container restarted, and it
kept the old target of five while holding two — reporting low_reserve_warning
true and preparing to buy three replacements, the exact opposite of the change
being made.

A bind-mounted single FILE pins its inode. git pull replaces the file rather than
editing it, so the container keeps reading the old one, and compose correctly
does nothing because the service definition is unchanged. Changes to config.alloy
were silently dead from 2026-08-25 until #301.

Not everything in the repo is deployed. deploy/Caddyfile and the Grafana JSON are
version controlled for review and history; nothing applies them. They drift, and
overwriting the live Caddyfile from the repo without diffing first once
reintroduced tls internal and broke TLS for two minutes.

The rule it lands on: changing a setting takes BOTH a gh variable set, so the next
deploy keeps it, AND an env file edit plus force-recreate, so it applies now.
Only the second and the next deploy reverts it; only the first and nothing changes
until someone deploys.

Docs only. Full suite: 1075 passed, 25 skipped.
* Stop strangers storing data on batches the gateway paid for

check_access() allowed any caller to use a batch absent from the ownership
registry, commented "backward compatibility" for batches predating it.

The set that default actually covered was not legacy callers. Every path by
which a caller obtains a batch already registers it — /pool/acquire,
POST /stamps/, /stamps/for-owner. What it covered was the POOL'S OWN INVENTORY:
ownership was recorded when a caller ACQUIRED a batch, never when the pool
bought one, so everything sitting in the pool was untracked and therefore
writable by anyone who knew its id. Batch ids are not secret; GET /stamps/
lists them.

A production pool batch is 50% utilised having never been acquired. That is
capacity the gateway funded, consumed by someone who never asked for it, on
batches it pays roughly 0.018-0.14 BZZ a day each to keep alive.

Two changes, in the order the fix requires:

Pool inventory is registered as POOL_OWNER at purchase (add_stamp_to_pool) and
adopted on sync, so batches bought before this change are protected too rather
than staying open for the rest of their lives. Registration failure never fails
a purchase — the batch exists and was paid for — but logs that it is
unprotected until registered.

check_access then refuses POOL_OWNER outright, and refuses untracked batches
instead of allowing them. Acquiring re-registers the batch to the caller, which
is the only way to get one.

STAMP_OWNERSHIP_ALLOW_UNTRACKED restores the old behaviour, for one situation:
STAMP_OWNERSHIP_FILE is lost, every batch becomes untracked at once, and
legitimate owners would be locked out of batches they paid for. A test pins that
permissive mode does NOT reach pool inventory — recovering from a lost registry
must not reopen the hole it is recovering from.

tests/test_stamp_ownership.py::test_untracked_stamp_allowed asserted the old
behaviour and now asserts the new one. Three tests added: permissive mode,
pool inventory refused to paid/free/anonymous callers alike, and permissive mode
not unlocking pool inventory.

Full suite: 1078 passed, 25 skipped.

Closes #312.

* Test that the pool claims what it buys, not just that the check works

The enforcement tests construct registry state by hand, so they pass whether or
not any code registers anything. Nothing asserted that add_stamp_to_pool or
sync_from_bee_node call register_stamp at all — meaning the lock was tested and
the door was not, which is the same asymmetry that let #312 stay open.

Three tests:

  a purchased batch is registered to POOL_OWNER with source pool_purchase
  a registration failure does not lose a batch already paid for
  sync adopts pre-existing inventory, so batches bought before this change are
    protected rather than staying open for the rest of their lives

The second matters because the batch exists on chain regardless: raising there
would drop it from the pool while the money is spent, which is worse than an
unprotected batch. It is logged instead, and the log says it is unprotected.

The sync test initially failed with synced == 0 — the fixture omitted `local` and
`usable`, which sync filters on. Fixed by matching the record shape the other
sync tests use.

TEST_STRATEGY.md records why both halves are needed.

Full suite: 1081 passed, 25 skipped.

* Report gateway inventory as 'pool', not as free for anyone to use

Found reviewing this PR. accessMode is computed as:

    "owned" if ownership_info.get("mode") == "paid" else "shared"

Pool inventory registers with mode="pool", which is not "paid", so it fell
through to "shared" — the value that tells a client the batch is free for anyone
to use, about exactly the batches check_access now refuses. A client reading the
listing would believe it and be denied on upload, which is worse than the
original defect in one respect: the API would be actively misleading rather than
merely permissive.

Neither existing value was honest. "shared" invites use; null means "unknown to
the registry", which it is not. So the Literal gains "pool" and the field
description says how to obtain one — acquiring re-registers it to the caller.

Two tests: the owner-to-accessMode mapping for all three cases, and that the
Literal permits "pool" (it is validated at serialisation, so an unlisted value
would fail at runtime rather than at import).

provenance-smasher asserts accessMode membership against a fixed list and would
have failed on every pool batch. Covered by provenance-smasher#31, which must
merge before this deploys.

Full suite: 1083 passed, 25 skipped.
@crtahlin
crtahlin merged commit 7accf3a into main Aug 30, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Untracked batches are writable by any caller — the pool's own inventory is untracked

1 participant